Skip to content

fix(csp): use Content-Length framing for MCP stdio transport - #89

Closed
Raikaru wants to merge 1 commit into
pleaseai:mainfrom
Raikaru:fix-mcp-stdio-framing
Closed

fix(csp): use Content-Length framing for MCP stdio transport#89
Raikaru wants to merge 1 commit into
pleaseai:mainfrom
Raikaru:fix-mcp-stdio-framing

Conversation

@Raikaru

@Raikaru Raikaru commented Sep 4, 2026

Copy link
Copy Markdown

Problem

csp mcp currently uses rmcp's built-in stdio() transport, which is newline-delimited JSON. The MCP stdio transport spec expects LSP-style Content-Length framing, so spec-compliant clients (Oh My Pi, Claude Code, etc.) receive a Parse error and cannot use the server.

Fix

  • Add a new ContentLengthTransport in crates/csp/src/bin/csp/mcp_transport.rs that reads/writes Content-Length framed messages.
  • Enable tokio's io-util feature for BufReader/BufWriter and the Async{BufRead,Read,Write}Ext traits.
  • Switch mcp_server::run_mcp to use the new transport.

Verification

  • cargo check -p code-search-please passes
  • cargo build -p code-search-please --release produces a csp binary
  • Manual test with a spec-compliant client shows initialize, tools/list, and tools/call (search) all working without parse errors.

Summary by cubic

Switches the MCP stdio transport to LSP-style Content-Length framing so spec-compliant clients (Oh My Pi, Claude Code) no longer get parse errors.

  • Adds ContentLengthTransport in crates/csp/src/bin/csp/mcp_transport.rs that reads and writes framed messages with size and header limits.
  • Enables tokio's io-util feature for buffered I/O traits.
  • Uses the new transport in run_mcp instead of rmcp's newline-delimited stdio().

Written for commit 0f74ec1. Summary will update on new commits.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a custom ContentLengthTransport to support LSP-style Content-Length framing for the MCP stdio transport, replacing the default newline-delimited transport. The feedback suggests several improvements to the new transport implementation: simplifying empty line detection by checking line.is_empty() after stripping suffixes, avoiding unnecessary UTF-8 validation by comparing the header name directly as a byte slice, and simplifying the skip_exact helper using tokio::io::copy and tokio::io::sink instead of manual buffer management.

Comment thread crates/csp/src/bin/csp/mcp_transport.rs Outdated
Comment on lines +126 to +133
if line == b"\r\n" || line == b"\n" || line.is_empty() {
// Empty line -> end of headers.
return content_length;
}

// Strip trailing newline and optional carriage return.
let line = line.strip_suffix(b"\n").unwrap_or(&line);
let line = line.strip_suffix(b"\r").unwrap_or(line);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Simplify empty line detection by checking after stripping suffixes

Symptom: The code checks for an empty line using a boolean expression with three conditions (line == b"\r\n" || line == b"\n" || line.is_empty()) before stripping the trailing newline and carriage return characters.
Source: Ousterhout — A Philosophy of Software Design (Ch. 4: Cognitive Overload / Complexity).
Consequence: The redundant checks increase cognitive load and make the header parsing loop slightly harder to read and maintain.
Remedy: Strip the trailing \r and \n first, then perform a single, unified check for line.is_empty().

Suggested change
if line == b"\r\n" || line == b"\n" || line.is_empty() {
// Empty line -> end of headers.
return content_length;
}
// Strip trailing newline and optional carriage return.
let line = line.strip_suffix(b"\n").unwrap_or(&line);
let line = line.strip_suffix(b"\r").unwrap_or(line);
// Strip trailing newline and optional carriage return.
let line = line.strip_suffix(b"\n").unwrap_or(&line);
let line = line.strip_suffix(b"\r").unwrap_or(line);
if line.is_empty() {
// Empty line -> end of headers.
return content_length;
}

Comment on lines +145 to +157
fn parse_content_length(line: &[u8]) -> Option<usize> {
let mut parts = line.splitn(2, |&b| b == b':');
let name = parts.next()?;
let value = parts.next()?;

let name = std::str::from_utf8(name).ok()?;
if !name.eq_ignore_ascii_case("Content-Length") {
return None;
}

let value = std::str::from_utf8(value).ok()?;
value.trim().parse::<usize>().ok()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Avoid unnecessary UTF-8 validation for header name

Symptom: The parse_content_length function converts the header name byte slice to a UTF-8 string before performing a case-insensitive comparison.
Source: Fowler — Refactoring (Primitive Obsession / unnecessary type conversion).
Consequence: Unnecessary UTF-8 validation is performed on the header name, which adds minor overhead and extra code.
Remedy: Use eq_ignore_ascii_case directly on the byte slice name with b"Content-Length".

fn parse_content_length(line: &[u8]) -> Option<usize> {
    let mut parts = line.splitn(2, |&b| b == b':');
    let name = parts.next()?;
    let value = parts.next()?;

    if !name.eq_ignore_ascii_case(b"Content-Length") {
        return None;
    }

    let value = std::str::from_utf8(value).ok()?;
    value.trim().parse::<usize>().ok()
}

Comment on lines +160 to +172
async fn skip_exact<R: AsyncReadExt + Unpin>(
read: &mut R,
mut n: usize,
) -> Result<(), std::io::Error> {
const CHUNK: usize = 4096;
let mut buf = vec![0u8; CHUNK];
while n > 0 {
let to_read = n.min(CHUNK);
read.read_exact(&mut buf[..to_read]).await?;
n -= to_read;
}
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Simplify skip_exact using tokio::io::copy and tokio::io::sink

Symptom: The skip_exact function manually manages a 4KB buffer and a loop to discard bytes from the reader.
Source: Fowler — Refactoring (Alternative Classes with Different Interfaces / standard library reuse).
Consequence: Manual buffer management and loop logic increase code complexity and the potential for bugs compared to using standard Tokio utilities.
Remedy: Use tokio::io::copy with read.take(n) and tokio::io::sink() to discard the bytes efficiently and cleanly.

async fn skip_exact<R: AsyncReadExt + Unpin>(
    read: &mut R,
    n: usize,
) -> Result<(), std::io::Error> {
    let mut discard = read.take(n as u64);
    tokio::io::copy(&mut discard, &mut tokio::io::sink()).await?;
    Ok(())
}

@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces rmcp's newline-delimited stdio transport with a custom Content-Length-framed transport and enables Tokio's required I/O utilities.

  • Adds framed JSON-RPC reads and writes with an 8 MiB body limit and parse-error responses.
  • Connects the new transport to the long-running MCP server.
  • Updates the Tokio feature configuration needed by the buffered asynchronous I/O implementation.

Confidence Score: 3/5

The PR should not merge until header parsing is bounded, because an MCP client can otherwise exhaust the server's memory through stdin.

The body-size guard runs only after unbounded header-line reads, leaving the long-running MCP process vulnerable to memory exhaustion from a client-controlled unterminated header.

Files Needing Attention: crates/csp/src/bin/csp/mcp_transport.rs

Security Review

The new parser leaves header lines unbounded, allowing a client controlling MCP stdin to exhaust process memory before the body-size limit is reached.

Important Files Changed

Filename Overview
crates/csp/src/bin/csp/mcp_transport.rs Adds the framing implementation, but header parsing allocates without a bound and exposes a client-triggered memory-exhaustion path.
crates/csp/src/bin/csp/mcp_server.rs Replaces rmcp's built-in stdio transport with the new Content-Length transport.
crates/csp/Cargo.toml Enables Tokio's io-util feature for buffered asynchronous reads and writes.
crates/csp/src/bin/csp/main.rs Registers the new transport module in the binary.

Sequence Diagram

sequenceDiagram
    participant Host as MCP host
    participant Transport as ContentLengthTransport
    participant Server as rmcp service
    Host->>Transport: Content-Length header + JSON body
    Transport->>Transport: Parse headers and bounded body
    Transport->>Server: JSON-RPC message
    Server-->>Transport: JSON-RPC response
    Transport-->>Host: Content-Length header + JSON body
Loading

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
crates/csp/src/bin/csp/mcp_transport.rs:119-120
**Unbounded MCP header allocation**

When an MCP client writes a long header line without a newline, `read_until` grows `line` without limit before the 8 MiB body check runs, causing memory exhaustion and making the MCP session unavailable. Bound both individual header lines and the complete header block while parsing. **How this was verified:** The client-controlled stdin reaches this unbounded allocation before any message-size guard is applied.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(csp): use Content-Length framing for..." | Re-trigger Greptile

Comment thread crates/csp/src/bin/csp/mcp_transport.rs Outdated
Comment on lines +119 to +120
let mut line = Vec::new();
match read.read_until(b'\n', &mut line).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Unbounded MCP header allocation

When an MCP client writes a long header line without a newline, read_until grows line without limit before the 8 MiB body check runs, causing memory exhaustion and making the MCP session unavailable. Bound both individual header lines and the complete header block while parsing. How this was verified: The client-controlled stdin reaches this unbounded allocation before any message-size guard is applied.

Knowledge Base Used: MCP server

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/csp/src/bin/csp/mcp_transport.rs
Line: 119-120

Comment:
**Unbounded MCP header allocation**

When an MCP client writes a long header line without a newline, `read_until` grows `line` without limit before the 8 MiB body check runs, causing memory exhaustion and making the MCP session unavailable. Bound both individual header lines and the complete header block while parsing. **How this was verified:** The client-controlled stdin reaches this unbounded allocation before any message-size guard is applied.

**Knowledge Base Used:** [MCP server](https://app.greptile.com/passionfactory/-/custom-context/knowledge-base/pleaseai/code-search/-/docs/mcp-server.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Architecture diagram
sequenceDiagram
    participant Client as MCP Client
    participant Stdio as csp MCP Process
    participant Transport as ContentLengthTransport
    participant Server as MCP Server
    participant Search as Code Search Engine

    Note over Client,Stdio: Process boundary: client communicates with csp over stdin/stdout
    Note over Transport: LSP-style framing: Content-Length header, blank line, JSON body
    Note over Stdio,Transport: Tokio BufReader and BufWriter provide asynchronous buffered I/O

    Client->>Stdio: Write request to stdin
    Stdio->>Transport: Read buffered bytes
    Transport->>Transport: Parse Content-Length header
    Transport->>Transport: Read exactly N body bytes
    Transport-->>Server: Decode framed JSON-RPC request

    alt initialize
        Server->>Server: Negotiate MCP protocol and capabilities
        Server-->>Transport: JSON-RPC initialize response
    else tools/list
        Server->>Server: Enumerate available tools
        Server-->>Transport: JSON-RPC tool list response
    else tools/call search
        Server->>Search: Execute code search with tool arguments
        Search-->>Server: Search results
        Server-->>Transport: JSON-RPC tool result response
    end

    Transport->>Transport: Serialize response JSON
    Transport->>Transport: Write Content-Length header and blank line
    Transport->>Stdio: Write framed response to stdout
    Stdio-->>Client: Content-Length framed JSON-RPC response

    alt Invalid or incomplete frame
        Transport-->>Server: Framing or parse error
        Server-->>Transport: JSON-RPC error response
        Transport->>Stdio: Write framed error to stdout
        Stdio-->>Client: Error response without newline-delimited parsing
    end
Loading

Re-trigger cubic

@codacy-production

codacy-production Bot commented Sep 4, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 4 duplication

Metric Results
Complexity 0
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

rmcp's stdio transport is newline-delimited, which does not conform to the
MCP stdio transport spec. Replace it with a custom transport that reads and
writes Content-Length framed messages, matching what standard MCP clients
(including Oh My Pi) expect.

- Add crates/csp/src/bin/csp/mcp_transport.rs implementing the framing
- Enable tokio io-util feature for BufReader/BufWriter/Async{Read,Write,BufRead}Ext
- Switch mcp_server::run_mcp to use ContentLengthTransport
@Raikaru
Raikaru force-pushed the fix-mcp-stdio-framing branch from 8507884 to 0f74ec1 Compare September 4, 2026 01:12
@Raikaru Raikaru closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant